contents
왜 코테에서 String 객체를 다루는 방법이 다른가
먼저, 흔한 오해를 바로잡는 것이 중요합니다. BufferedReader와 BufferedWriter는 문자열 조작 자체를 더 빠르게 만들지 않습니다. String 객체를 합치거나 분리하는 등의 연산 속도를 높여주는 것이 아닙니다. 대신, 파일, 네트워크, 콘솔과 같은 소스(source)로부터 문자열을 읽고 쓰는 I/O(입출력) 과정을 극적으로 빠르게 만듭니다.
그 이유는 비용이 비싼 I/O 작업의 횟수를 줄이는 것이라는 한 가지 핵심 개념으로 귀결됩니다.
핵심 문제: 비싼 I/O 호출의 비효율성 🐢
프로그램이 파일에서 읽거나 콘솔에 써야 할 때, 직접 그 작업을 할 수 없습니다. 운영체제(OS)에 작업을 수행해달라고 요청해야 합니다. 이 요청을 시스템 콜(system call) 이라고 합니다.
시스템 콜은 컴퓨터 연산상 비용이 비쌉니다. 시스템 콜이 발생할 때마다 CPU는 컨텍스트 스위치(context switch) 를 수행해야 합니다.
- ("사용자 모드"에서 실행 중인) 애플리케이션의 실행을 일시 중지합니다.
- OS가 제어권을 갖도록 고도로 권한이 부여된 "커널 모드"로 전환합니다.
- OS가 요청된 I/O 작업을 수행합니다(예: 디스크에서 아주 작은 데이터 조각을 읽음).
- 다시 커널 모드에서 사용자 모드로 전환합니다.
- 애플리케이션 실행을 재개합니다.
이 과정은 상당한 오버헤드를 가집니다. 이제, 기본적인 FileReader를 사용하여 한 번에 한 문자씩 대용량 텍스트 파일을 읽는다고 상상해 보세요.
// 느린 방식
FileReader reader = new FileReader("large_file.txt");
int character;
while ((character = reader.read()) != -1) {
// 문자 처리
}
이 시나리오에서는, 단 하나의 문자를 읽을 때마다 별도의 비싼 시스템 콜을 발생시킬 수 있습니다. 만약 파일에 백만 개의 문자가 있다면, 백만 번의 컨텍스트 스위치가 발생할 수 있습니다. 이는 엄청나게 비효율적입니다.
비유: 이것은 케이크 재료를 사러 슈퍼마켓에 가는 것과 같습니다. 하지만 모든 재료를 한 번에 사는 대신, 계란 하나를 사러 가게에 갔다가 집에 오고, 다시 밀가루를 사러 갔다가 집에 오고, 또 우유를 사러 가는 식입니다. "가게에 운전해서 가는 것"이 비싼 시스템 콜이고, "재료"가 작은 데이터 조각입니다.
해결책: 버퍼링 (Buffering) 🚀
BufferedReader와 BufferedWriter는 기존의 Reader 및 Writer 객체를 감싸는 래퍼(wrapper) 또는 "데코레이터(decorator)" 역할을 하여 이 문제를 해결합니다. 이들은 메모리에 임시 저장 공간인 버퍼(buffer) 를 도입합니다 (기본적으로 보통 8KB 크기의 char 배열).
BufferedReader의 작동 방식
BufferedReader에게 데이터(예: read()나 readLine() 호출)를 요청하면, 단순히 요청한 만큼만 가져오지 않습니다.
- 내부 버퍼를 완전히 채우기 위해 디스크의 파일과 같은 기본 소스에 단 한 번의 큰 시스템 콜을 합니다.
- 많은 양의 데이터를 읽어와 내부 버퍼를 채웁니다.
- 그런 다음, 사용자의
read()요청에 대해 이 빠른 인메모리 버퍼에서 직접 데이터를 제공합니다. - 버퍼가 비워졌을 때만, 버퍼를 다시 채우기 위해 또 다른 비싼 시스템 콜을 합니다.
비유: 이제, 당신은 큰 쇼핑 카트(버퍼)를 가지고 슈퍼마켓에 갑니다. 한 번의 비싼 이동(시스템 콜)으로 카트를 가득 채웁니다. 그 후 하루 종일 재료가 필요할 때마다, 매우 빠른 속도로 집의 식료품 저장실(인메모리 버퍼)에서 그냥 꺼내 쓰기만 하면 됩니다.
BufferedWriter의 작동 방식
BufferedWriter는 반대로 작동합니다. write()를 호출하면, 데이터를 즉시 목적지로 보내지 않습니다.
- 문자열 데이터를 메모리 내의 내부 버퍼에 씁니다.
- 계속해서
write()호출이 있을 때마다 버퍼에 데이터를 모읍니다. - 버퍼가 가득 차거나, 사용자가 명시적으로
writer.flush()또는writer.close()를 호출했을 때만, 단 한 번의 큰 시스템 콜을 수행하여 버퍼의 전체 내용을 목적지에 씁니다.
실제 코드 예제
파일에 많은 수의 줄을 쓸 때의 차이점을 살펴봅시다.
예제 1: 느린 방식 (직접 I/O)
import java.io.FileWriter;
import java.io.IOException;
public class SlowWriter {
public static void main(String[] args) {
try (FileWriter writer = new FileWriter("output_slow.txt")) {
for (int i = 0; i < 100000; i++) {
// 각 write 호출이 잠재적으로 시스템 콜을 유발할 수 있음
writer.write("This is line number " + i + "\n");
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
예제 2: 빠른 방식 (버퍼링된 I/O)
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
public class FastWriter {
public static void main(String[] args) {
// FileWriter를 BufferedWriter로 감쌉니다.
try (BufferedWriter writer = new BufferedWriter(new FileWriter("output_fast.txt"))) {
for (int i = 0; i < 100000; i++) {
// 먼저 인메모리 버퍼에 씁니다. 빠릅니다!
writer.write("This is line number " + i);
writer.newLine(); // 플랫폼에 독립적인 방식으로 줄바꿈을 씁니다.
}
// try-with-resources 블록이 writer를 닫을 때 버퍼는 자동으로 flush 됩니다.
} catch (IOException e) {
e.printStackTrace();
}
}
}
이 두 예제를 실행하면, FastWriter가 SlowWriter보다 훨씬 짧은 시간에 완료될 것입니다.
언제 가장 중요한가?
- 경쟁 프로그래밍 (알고리즘 문제 풀이):
System.in에서 대량의 입력을 읽는 것은 고전적인 병목 현상입니다.Scanner는 편리하지만 너무 느릴 수 있습니다. 빠른 입력을 위한 표준은BufferedReader(new InputStreamReader(System.in))입니다. - 대용량 파일 처리: 메가바이트나 기가바이트 크기의 텍스트 파일을 읽거나 쓸 때, 성능 차이는 단지 눈에 띄는 수준이 아니라 엄청납니다.
결론
요약하자면, BufferedReader와 BufferedWriter는 I/O 성능을 극적으로 향상시키기 때문에 사용해야 합니다. 이들은 운영체제에 대한 느린 시스템 콜의 횟수를 최소화하고, 대신 OS와 효율적인 인메모리 버퍼 간에 대량 읽기/쓰기 작업을 수행함으로써 이를 달성합니다.
왜 C++과 python에는 그런 게 없을까요
자바에 비해 이런 기능이 없는 것처럼 보이는 이유는 개발자에게 그 기능을 제시하는 방식이 다르기 때문입니다.
차이점: 명시적인 래퍼 vs. 스마트한 기본값
- 자바 ☕: 자바의 표준 라이브러리는 데코레이터 패턴에 크게 기반을 둡니다.
FileReader와 같이 버퍼링 기능이 없는 기본적인 객체로 시작해서,BufferedReader같은 다른 클래스로 명시적으로 "감싸서(wrapping)" 버퍼링과 같은 기능을 추가해야 합니다. 이 방식은 매우 유연하지만, 버퍼를 추가해야 한다는 사실을 개발자가 알고 있어야 합니다. - C++ & 파이썬: 이 언어들은 "필요한 기능이 내장된(batteries-included)" 접근 방식을 취합니다. 대부분의 파일 및 콘솔 I/O에서 버퍼링이 훨씬 효율적이기 때문에, 표준 I/O 객체들이 기본적으로 버퍼링되도록 설계되었습니다. 성능 최적화가 이미 내장되어 있으므로 특별한 래퍼를 추가할 필요가 없습니다.
각 언어를 자세히 살펴보겠습니다.
🇮🇹 C++: iostream을 통한 기본 버퍼링
C++에서 표준 I/O 라이브러리(<iostream>)와 파일 스트림 라이브러리(<fstream>)는 기본적으로 버퍼링됩니다.
std::cin, std::cout 또는 std::ofstream을 사용하여 파일에 쓸 때, 모든 문자나 줄에 대해 시스템 콜을 하는 것이 아닙니다. 데이터는 먼저 std::streambuf 객체가 관리하는 내부 버퍼에 쓰여집니다.
C++에서 버퍼링 제어하기:
버퍼는 다음과 같은 경우에 자동으로 플러시(flush, OS에 내용을 기록)됩니다.
- 버퍼가 가득 찼을 때.
- 명시적으로 플러시할 때.
- 스트림이 닫힐 때.
이것이 '\n'과 std::endl 사이에 성능 차이가 나는 이유입니다.
std::cout << "Hello" << '\n';// 버퍼에 줄바꿈 문자를 씁니다.std::cout << "World" << std::endl;// 버퍼에 줄바꿈 문자를 쓰고 버퍼를 플러시합니다.
std::endl을 과도하게 사용하면 버퍼의 목적을 무너뜨리고 잦은 시스템 콜을 강제하여 성능을 저해할 수 있습니다.
경쟁 프로그래밍 팁:
C++ 경쟁 프로그래밍에서는 cin과 cout의 속도를 더 높이기 위해 종종 다음 두 줄을 사용합니다.
// cin을 cout과 분리하고, C++ 스트림과 C 표준 스트림의 동기화를 해제합니다.
std::ios_base::sync_with_stdio(false);
std::cin.tie(nullptr);
이는 C++의 버퍼링된 스트림에게 C의 구식 I/O 스트림과 동기화할 필요가 없다고 알려주어 상당한 속도 향상을 가져옵니다.
🐍 파이썬: io 모듈을 통한 기본 버퍼링
파이썬의 I/O 또한 기본적으로 버퍼링됩니다. 내장 함수인 open()을 사용하면 버퍼링된 파일 객체를 얻게 됩니다.
# 'f' 객체는 TextIOWrapper이며, 내부적으로 BufferedWriter와 BufferedReader를 사용합니다.
with open("my_file.txt", "w") as f:
f.write("이 줄은 먼저 인메모리 버퍼에 쓰여집니다.\n")
# 'with' 블록이 종료될 때 버퍼는 자동으로 플러시되고 파일은 닫힙니다.
파이썬에서 버퍼링 제어하기:
파이썬의 open() 함수는 buffering 인자를 통해 버퍼링을 명시적으로 제어할 수 있게 해줍니다.
open(file, mode, buffering=0): 버퍼링을 비활성화합니다 (쓰기가 OS로 바로 전달됨). 매우 느립니다.open(file, mode, buffering=1): 라인 버퍼링을 활성화합니다. 줄바꿈 문자가 쓰일 때마다 버퍼가 플러시됩니다. 로그 파일에 유용합니다.open(file, mode, buffering=-1): 시스템의 기본 버퍼 크기를 사용합니다 (이것이 기본 동작입니다).open(file, mode, buffering=8192): 특정 크기(바이트 단위)의 버퍼를 사용합니다.
경쟁 프로그래밍 팁:
이것이 파이썬에서 input()은 느리고 sys.stdin.readline()은 빠른 이유입니다.
input(): 단순히 한 줄을 읽는 것 이상의 일을 합니다. 프롬프트를 처리하고, 출력을 플러시하며, 줄바꿈 문자를 제거합니다.sys.stdin.readline(): 표준 입력 스트림에서 훨씬 더 직접적이고 버퍼링된 읽기를 수행하여, 대량의 데이터를 읽을 때 훨씬 빠릅니다.
결론: 철학의 차이
결론적으로, 이 기능은 전혀 빠져있지 않습니다. 차이점은 단지 설계 철학에 있습니다.
| 언어 | 철학 | 버퍼를 얻는 방법 |
|---|---|---|
| 자바 | 명시적인 데코레이션: 기본부터 시작해서 필요한 것을 추가합니다. | 기본적인 Reader/Writer를 BufferedReader/BufferedWriter로 감싸야 합니다. |
| C++ / 파이썬 | 스마트한 기본값: 가장 일반적이고 성능 좋은 옵션을 기본으로 제공합니다. | 표준 I/O 객체(cout, open())가 이미 버퍼링되어 있습니다. |
자바는 성능을 위해 명시적으로 요청하도록 만드는 반면, C++과 파이썬은 기본적으로 성능 좋은 옵션을 제공하고, 정말로 필요할 때만 더 느린 비버퍼링 동작을 명시적으로 요청하도록 만듭니다.
references